sih-br-mcp 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -10,6 +10,7 @@ import { eraNotes, resetSidecars, loadSidecars, provenanceFor, raceNotes, withPr
10
10
  import { getFreshness } from "./freshness.js";
11
11
  import { outputSchemaFor } from "./output-schemas.js";
12
12
  import { CLASSE_DO_ERRO_META, classeDaExcecao, withUpstreamCall } from "./upstream.js";
13
+ import { FalhaDaFonte } from "./erros.js";
13
14
  import { CUBES_BASE_URL, CUBES_CACHE_ENABLED, cachedCubesManifest, causasSummaryState, cubesCacheDir, ensureCausasEstratosYears, ensureCausasSummary, ensureEstratosYears, ensureIcsapSummary, ensurePopulation, ensureSidecars, ensureYears, icsapSummaryState, loadCubesManifest, populationPresent, publishedYears, yearsFromArgs } from "./cache.js";
14
15
  // =============================================================================
15
16
  // DEFINIÇÃO DAS FERRAMENTAS
@@ -626,63 +627,55 @@ async function describeCubesChannel() {
626
627
  };
627
628
  }
628
629
  async function handleGetAvailableYears() {
629
- try {
630
- const years = getAvailableYears();
631
- const noRace = yearsWithoutRace();
632
- // Sidecars dos anos carregados (builder >= 2.5.0 traz os campos da era
633
- // antiga; um sidecar anterior cai nos valores de 1998+).
634
- const sidecars = loadSidecars().filter((s) => years.includes(s.cube_year));
635
- const byYear = (f) => Object.fromEntries(sidecars.map((s) => [String(s.cube_year), f(s)]));
636
- return {
637
- years,
638
- data_range: {
639
- first_year: years[0],
640
- last_year: years[years.length - 1],
641
- total_years: years.length,
642
- },
643
- note: "Anos com dados Parquet disponíveis",
644
- // Raça/cor por ano (sidecar `columns_missing`, builder >= 2.4.0): RACA_COR
645
- // só entra no leiaute da AIH em 2008; em 1998–2007 `race` é nulo.
646
- race_available: Object.fromEntries(years.map((y) => [String(y), !noRace.includes(y)])),
647
- years_without_race: noRace,
648
- // Era antiga 1992–1997 (sidecar do builder >= 2.5.0; docs/analise-002 e
649
- // analise-003): revisão da CID por ano (internações por revisão; 1997 tem
650
- // as duas), lista ICSAP por revisão, base do eixo `uf`, município,
651
- // moeda de `value` e internações sem data na fonte.
652
- cid_revision: byYear((s) => s.cid_revision ?? { "10": s.totals?.records_in_cube ?? null }),
653
- years_cid9: yearsCid9(),
654
- icsap_available: byYear(() => true),
655
- icsap_list_revision: byYear((s) => s.icsap_list_revision ?? { "10": "portaria-221-2008" }),
656
- uf_basis: byYear((s) => s.uf_basis ?? "residencia"),
657
- years_uf_arquivo: yearsUfArquivo(),
658
- municipality_available: byYear((s) => s.municipality_available ?? true),
659
- currency: byYear((s) => s.currency ?? null),
660
- records_date_imputed: byYear((s) => s.records_date_imputed ?? 0),
661
- // Universo do % ICSAP (builder >= 2.6.0, csapAIH): internações dentro do
662
- // universo e fora dele por motivo; null em cubo anterior a 2.6.0.
663
- csap_universe: byYear((s) => s.csap_universe
664
- ? { method: s.csap_universe.method, records_in_universe: s.csap_universe.records_in_universe, excluded: s.csap_universe.excluded }
665
- : null),
666
- // Cobertura dos arquivos de população, lida deles: é o que as ferramentas
667
- // de taxa aceitam — `detailed` (idade simples, 2000+) e `aggregated`
668
- // (faixa etária quinquenal, 1991–1999; sih:taxas-1992-1999).
669
- population_years: await getPopulationCoverage(),
670
- // Canal público dos cubos (src/cache.ts): o que existe para baixar e onde
671
- // o cache local vive. `published_years` vem do manifest.json do canal
672
- // (timeout curto; cópia em disco quando a rede falha); `years` acima são
673
- // os cubos já presentes localmente.
674
- cubes_channel: await describeCubesChannel(),
675
- // Frescor dos cubos frente ao espelho healthbr-data (src/freshness.ts):
676
- // checado em segundo plano na inicialização, sem bloquear.
677
- freshness: getFreshness(),
678
- };
679
- }
680
- catch (error) {
681
- return {
682
- error: error instanceof Error ? error.message : "Erro ao listar anos",
683
- years: [],
684
- };
685
- }
630
+ const years = getAvailableYears();
631
+ const noRace = yearsWithoutRace();
632
+ // Sidecars dos anos carregados (builder >= 2.5.0 traz os campos da era
633
+ // antiga; um sidecar anterior cai nos valores de 1998+).
634
+ const sidecars = loadSidecars().filter((s) => years.includes(s.cube_year));
635
+ const byYear = (f) => Object.fromEntries(sidecars.map((s) => [String(s.cube_year), f(s)]));
636
+ return {
637
+ years,
638
+ data_range: {
639
+ first_year: years[0],
640
+ last_year: years[years.length - 1],
641
+ total_years: years.length,
642
+ },
643
+ note: "Anos com dados Parquet disponíveis",
644
+ // Raça/cor por ano (sidecar `columns_missing`, builder >= 2.4.0): RACA_COR
645
+ // só entra no leiaute da AIH em 2008; em 1998–2007 `race` é nulo.
646
+ race_available: Object.fromEntries(years.map((y) => [String(y), !noRace.includes(y)])),
647
+ years_without_race: noRace,
648
+ // Era antiga 1992–1997 (sidecar do builder >= 2.5.0; docs/analise-002 e
649
+ // analise-003): revisão da CID por ano (internações por revisão; 1997 tem
650
+ // as duas), lista ICSAP por revisão, base do eixo `uf`, município,
651
+ // moeda de `value` e internações sem data na fonte.
652
+ cid_revision: byYear((s) => s.cid_revision ?? { "10": s.totals?.records_in_cube ?? null }),
653
+ years_cid9: yearsCid9(),
654
+ icsap_available: byYear(() => true),
655
+ icsap_list_revision: byYear((s) => s.icsap_list_revision ?? { "10": "portaria-221-2008" }),
656
+ uf_basis: byYear((s) => s.uf_basis ?? "residencia"),
657
+ years_uf_arquivo: yearsUfArquivo(),
658
+ municipality_available: byYear((s) => s.municipality_available ?? true),
659
+ currency: byYear((s) => s.currency ?? null),
660
+ records_date_imputed: byYear((s) => s.records_date_imputed ?? 0),
661
+ // Universo do % ICSAP (builder >= 2.6.0, csapAIH): internações dentro do
662
+ // universo e fora dele por motivo; null em cubo anterior a 2.6.0.
663
+ csap_universe: byYear((s) => s.csap_universe
664
+ ? { method: s.csap_universe.method, records_in_universe: s.csap_universe.records_in_universe, excluded: s.csap_universe.excluded }
665
+ : null),
666
+ // Cobertura dos arquivos de população, lida deles: é o que as ferramentas
667
+ // de taxa aceitam — `detailed` (idade simples, 2000+) e `aggregated`
668
+ // (faixa etária quinquenal, 1991–1999; sih:taxas-1992-1999).
669
+ population_years: await getPopulationCoverage(),
670
+ // Canal público dos cubos (src/cache.ts): o que existe para baixar e onde
671
+ // o cache local vive. `published_years` vem do manifest.json do canal
672
+ // (timeout curto; cópia em disco quando a rede falha); `years` acima são
673
+ // os cubos já presentes localmente.
674
+ cubes_channel: await describeCubesChannel(),
675
+ // Frescor dos cubos frente ao espelho healthbr-data (src/freshness.ts):
676
+ // checado em segundo plano na inicialização, sem bloquear.
677
+ freshness: getFreshness(),
678
+ };
686
679
  }
687
680
  /**
688
681
  * Forma de um código CID-10: letra + 2 dígitos, com um 4º caractere opcional
@@ -804,145 +797,121 @@ async function handleGetHospitalizations(args) {
804
797
  races: args.race,
805
798
  isCsap: args.is_csap,
806
799
  };
807
- try {
808
- const data = await queryCausas({
809
- filters,
810
- groupBy: args.group_by,
811
- metrics: ["n", "days", "value", "deaths"],
812
- orderBy: args.group_by?.includes("year")
813
- ? "year"
814
- : args.group_by?.[0] || "n_hospitalizations DESC",
815
- limit: args.limit,
816
- });
817
- const totals = data.reduce((acc, row) => ({
818
- n: acc.n + (Number(row.n_hospitalizations) || 0),
819
- days: acc.days + (Number(row.total_days) || 0),
820
- value: acc.value + (Number(row.total_value) || 0),
821
- deaths: acc.deaths + (Number(row.deaths) || 0),
822
- }), { n: 0, days: 0, value: 0, deaths: 0 });
823
- const raceN = args.race?.length || args.group_by?.includes("race") ? raceNotes(args.year, causasYearsAvailable()) : [];
824
- return {
825
- data,
826
- ...notesField(args.year, { value: true, month: !!args.month?.length || !!args.group_by?.includes("month") }, raceN),
827
- summary: {
828
- total_hospitalizations: totals.n,
829
- total_days: totals.days,
830
- total_value: Math.round(totals.value * 100) / 100,
831
- deaths: totals.deaths,
832
- hospital_mortality_rate: totals.n > 0 ? Math.round((totals.deaths / totals.n) * 10000) / 100 : 0,
833
- records_returned: data.length,
834
- },
835
- filters_applied: args,
836
- };
837
- }
838
- catch (error) {
839
- return {
840
- error: error instanceof Error ? error.message : "Erro na consulta",
841
- data: [],
842
- };
843
- }
800
+ const data = await queryCausas({
801
+ filters,
802
+ groupBy: args.group_by,
803
+ metrics: ["n", "days", "value", "deaths"],
804
+ orderBy: args.group_by?.includes("year")
805
+ ? "year"
806
+ : args.group_by?.[0] || "n_hospitalizations DESC",
807
+ limit: args.limit,
808
+ });
809
+ const totals = data.reduce((acc, row) => ({
810
+ n: acc.n + (Number(row.n_hospitalizations) || 0),
811
+ days: acc.days + (Number(row.total_days) || 0),
812
+ value: acc.value + (Number(row.total_value) || 0),
813
+ deaths: acc.deaths + (Number(row.deaths) || 0),
814
+ }), { n: 0, days: 0, value: 0, deaths: 0 });
815
+ const raceN = args.race?.length || args.group_by?.includes("race") ? raceNotes(args.year, causasYearsAvailable()) : [];
816
+ return {
817
+ data,
818
+ ...notesField(args.year, { value: true, month: !!args.month?.length || !!args.group_by?.includes("month") }, raceN),
819
+ summary: {
820
+ total_hospitalizations: totals.n,
821
+ total_days: totals.days,
822
+ total_value: Math.round(totals.value * 100) / 100,
823
+ deaths: totals.deaths,
824
+ hospital_mortality_rate: totals.n > 0 ? Math.round((totals.deaths / totals.n) * 10000) / 100 : 0,
825
+ records_returned: data.length,
826
+ },
827
+ filters_applied: args,
828
+ };
844
829
  }
845
830
  async function handleGetHospitalizationTrends(args) {
846
831
  const { year_start, year_end, uf, cid_chapter, granularity = "yearly" } = args;
847
- try {
848
- if (granularity === "monthly") {
849
- // Usa cubo de séries temporais
850
- const data = await querySeries({
851
- filters: {
852
- yearMonthStart: `${year_start}-01`,
853
- yearMonthEnd: `${year_end}-12`,
854
- ufs: uf,
855
- cidChapters: cid_chapter ? [cid_chapter] : undefined,
856
- },
857
- groupBy: ["year_month"],
858
- orderBy: "year_month",
859
- });
860
- return {
861
- granularity: "monthly",
862
- series: data,
863
- period: { start: `${year_start}-01`, end: `${year_end}-12` },
864
- ...notesField(yearsFromArgs(args) ?? undefined, { month: true }),
865
- };
866
- }
867
- else {
868
- // Agregação anual do CUBO DE SÉRIES (0.14.1; era do de causas): mesma
869
- // resposta, provada grupo a grupo — e o cache baixa ~40 KB/ano em vez
870
- // dos três cubos (~70 MB/ano). Foi a pergunta "total do Brasil desde
871
- // 1992" levando 4 min no chat que expôs a diferença.
872
- const years = [];
873
- for (let y = year_start; y <= year_end; y++) {
874
- years.push(y);
875
- }
876
- const data = await querySeriesYearly({
877
- years,
832
+ if (granularity === "monthly") {
833
+ // Usa cubo de séries temporais
834
+ const data = await querySeries({
835
+ filters: {
836
+ yearMonthStart: `${year_start}-01`,
837
+ yearMonthEnd: `${year_end}-12`,
878
838
  ufs: uf,
879
839
  cidChapters: cid_chapter ? [cid_chapter] : undefined,
880
- });
881
- return {
882
- granularity: "yearly",
883
- series: data,
884
- period: { start: year_start, end: year_end },
885
- ...notesField(yearsFromArgs(args) ?? undefined, { month: args.granularity === "monthly" }),
886
- };
887
- }
840
+ },
841
+ groupBy: ["year_month"],
842
+ orderBy: "year_month",
843
+ });
844
+ return {
845
+ granularity: "monthly",
846
+ series: data,
847
+ period: { start: `${year_start}-01`, end: `${year_end}-12` },
848
+ ...notesField(yearsFromArgs(args) ?? undefined, { month: true }),
849
+ };
888
850
  }
889
- catch (error) {
851
+ else {
852
+ // Agregação anual do CUBO DE SÉRIES (0.14.1; era do de causas): mesma
853
+ // resposta, provada grupo a grupo — e o cache baixa ~40 KB/ano em vez
854
+ // dos três cubos (~70 MB/ano). Foi a pergunta "total do Brasil desde
855
+ // 1992" levando 4 min no chat que expôs a diferença.
856
+ const years = [];
857
+ for (let y = year_start; y <= year_end; y++) {
858
+ years.push(y);
859
+ }
860
+ const data = await querySeriesYearly({
861
+ years,
862
+ ufs: uf,
863
+ cidChapters: cid_chapter ? [cid_chapter] : undefined,
864
+ });
890
865
  return {
891
- error: error instanceof Error ? error.message : "Erro na consulta",
892
- series: [],
866
+ granularity: "yearly",
867
+ series: data,
868
+ period: { start: year_start, end: year_end },
869
+ ...notesField(yearsFromArgs(args) ?? undefined, { month: args.granularity === "monthly" }),
893
870
  };
894
871
  }
895
872
  }
896
873
  async function handleCompareRegions(args) {
897
874
  const { year, compare_by = "uf", cid_chapter, is_csap, metric = "n", limit = 10 } = args;
898
- try {
899
- const groupByField = compare_by === "region" ? "uf" : "uf"; // TODO: agregar por região
900
- // Cubo LEVE quando o recorte cabe nele (0.14.2): esta ferramenta devolve só
901
- // contagem e óbitos, que as séries têm — 1,2 MB nos 34 anos contra 1.253 MB.
902
- const orderBy = metric === "n" ? "n_hospitalizations DESC" : "deaths DESC";
903
- const data = seriesFitsCall("compare_regions", args)
904
- ? await querySeriesYearly({
905
- years: year ?? [],
875
+ const groupByField = compare_by === "region" ? "uf" : "uf"; // TODO: agregar por região
876
+ // Cubo LEVE quando o recorte cabe nele (0.14.2): esta ferramenta devolve só
877
+ // contagem e óbitos, que as séries têm — 1,2 MB nos 34 anos contra 1.253 MB.
878
+ const orderBy = metric === "n" ? "n_hospitalizations DESC" : "deaths DESC";
879
+ const data = seriesFitsCall("compare_regions", args)
880
+ ? await querySeriesYearly({
881
+ years: year ?? [],
882
+ cidChapters: cid_chapter ? [cid_chapter] : undefined,
883
+ groupBy: [groupByField],
884
+ orderBy,
885
+ limit,
886
+ })
887
+ : await queryCausas({
888
+ filters: {
889
+ years: year,
906
890
  cidChapters: cid_chapter ? [cid_chapter] : undefined,
907
- groupBy: [groupByField],
908
- orderBy,
909
- limit,
910
- })
911
- : await queryCausas({
912
- filters: {
913
- years: year,
914
- cidChapters: cid_chapter ? [cid_chapter] : undefined,
915
- isCsap: is_csap,
916
- },
917
- groupBy: [groupByField],
918
- metrics: ["n", "deaths"],
919
- orderBy,
920
- limit,
921
- });
922
- // Adiciona ranking
923
- const ranking = data.map((row, index) => ({
924
- rank: index + 1,
925
- uf: row.uf,
926
- n_hospitalizations: row.n_hospitalizations,
927
- deaths: row.deaths,
928
- mortality_rate: Number(row.n_hospitalizations) > 0
929
- ? Math.round((Number(row.deaths) / Number(row.n_hospitalizations)) * 10000) / 100
930
- : 0,
931
- }));
932
- return {
933
- compare_by,
934
- metric,
935
- ranking,
936
- total_locations: data.length,
937
- ...notesField(args.year, {}),
938
- };
939
- }
940
- catch (error) {
941
- return {
942
- error: error instanceof Error ? error.message : "Erro na consulta",
943
- ranking: [],
944
- };
945
- }
891
+ isCsap: is_csap,
892
+ },
893
+ groupBy: [groupByField],
894
+ metrics: ["n", "deaths"],
895
+ orderBy,
896
+ limit,
897
+ });
898
+ // Adiciona ranking
899
+ const ranking = data.map((row, index) => ({
900
+ rank: index + 1,
901
+ uf: row.uf,
902
+ n_hospitalizations: row.n_hospitalizations,
903
+ deaths: row.deaths,
904
+ mortality_rate: Number(row.n_hospitalizations) > 0
905
+ ? Math.round((Number(row.deaths) / Number(row.n_hospitalizations)) * 10000) / 100
906
+ : 0,
907
+ }));
908
+ return {
909
+ compare_by,
910
+ metric,
911
+ ranking,
912
+ total_locations: data.length,
913
+ ...notesField(args.year, {}),
914
+ };
946
915
  }
947
916
  async function handleGetIcsap(args) {
948
917
  const filters = {
@@ -955,51 +924,43 @@ async function handleGetIcsap(args) {
955
924
  ageMax: args.age_max,
956
925
  races: args.race,
957
926
  };
958
- try {
959
- const data = await queryIcsap({
960
- filters,
961
- groupBy: args.group_by,
962
- metrics: ["n", "n_total", "days", "value", "deaths"],
963
- universe: args.universe,
964
- orderBy: args.group_by?.includes("year")
965
- ? "year"
966
- : args.group_by?.[0] || "n_icsap DESC",
967
- });
968
- // Totais do filtro inteiro, numa consulta sem agrupamento (0.9.0): somar
969
- // as linhas de `data` repetiria n_total sempre que o agrupamento divide o
970
- // estrato (por grupo CSAP) — o denominador vem dos estratos distintos.
971
- const [whole] = await calculateIcsapIndicators({ filters, groupBy: [], universe: args.universe });
972
- const totals = {
973
- icsap: Number(whole?.n_icsap) || 0,
974
- total: Number(whole?.n_total) || 0,
975
- days: Number(whole?.total_days) || 0,
976
- value: Number(whole?.total_value) || 0,
977
- deaths: Number(whole?.deaths) || 0,
978
- };
979
- const raceN = args.race?.length || args.group_by?.includes("race") ? raceNotes(args.year, getAvailableYears()) : [];
980
- return {
981
- data,
982
- ...notesField(args.year, { icsap: true, value: true, municipality: !!args.municipality_code || !!args.group_by?.includes("municipality_code") }, [...raceN, universeNote(args.universe)]),
983
- summary: {
984
- total_icsap: totals.icsap,
985
- total_hospitalizations: totals.total,
986
- icsap_percentage: totals.total > 0
987
- ? Math.round((totals.icsap / totals.total) * 10000) / 100
988
- : 0,
989
- total_days: totals.days,
990
- total_value: Math.round(totals.value * 100) / 100,
991
- deaths: totals.deaths,
992
- records_returned: data.length,
993
- },
994
- filters_applied: args,
995
- };
996
- }
997
- catch (error) {
998
- return {
999
- error: error instanceof Error ? error.message : "Erro na consulta",
1000
- data: [],
1001
- };
1002
- }
927
+ const data = await queryIcsap({
928
+ filters,
929
+ groupBy: args.group_by,
930
+ metrics: ["n", "n_total", "days", "value", "deaths"],
931
+ universe: args.universe,
932
+ orderBy: args.group_by?.includes("year")
933
+ ? "year"
934
+ : args.group_by?.[0] || "n_icsap DESC",
935
+ });
936
+ // Totais do filtro inteiro, numa consulta sem agrupamento (0.9.0): somar
937
+ // as linhas de `data` repetiria n_total sempre que o agrupamento divide o
938
+ // estrato (por grupo CSAP) — o denominador vem dos estratos distintos.
939
+ const [whole] = await calculateIcsapIndicators({ filters, groupBy: [], universe: args.universe });
940
+ const totals = {
941
+ icsap: Number(whole?.n_icsap) || 0,
942
+ total: Number(whole?.n_total) || 0,
943
+ days: Number(whole?.total_days) || 0,
944
+ value: Number(whole?.total_value) || 0,
945
+ deaths: Number(whole?.deaths) || 0,
946
+ };
947
+ const raceN = args.race?.length || args.group_by?.includes("race") ? raceNotes(args.year, getAvailableYears()) : [];
948
+ return {
949
+ data,
950
+ ...notesField(args.year, { icsap: true, value: true, municipality: !!args.municipality_code || !!args.group_by?.includes("municipality_code") }, [...raceN, universeNote(args.universe)]),
951
+ summary: {
952
+ total_icsap: totals.icsap,
953
+ total_hospitalizations: totals.total,
954
+ icsap_percentage: totals.total > 0
955
+ ? Math.round((totals.icsap / totals.total) * 10000) / 100
956
+ : 0,
957
+ total_days: totals.days,
958
+ total_value: Math.round(totals.value * 100) / 100,
959
+ deaths: totals.deaths,
960
+ records_returned: data.length,
961
+ },
962
+ filters_applied: args,
963
+ };
1003
964
  }
1004
965
  async function handleGetIcsapIndicators(args) {
1005
966
  const filters = {
@@ -1010,26 +971,18 @@ async function handleGetIcsapIndicators(args) {
1010
971
  ageMin: args.age_min,
1011
972
  ageMax: args.age_max,
1012
973
  };
1013
- try {
1014
- const data = await calculateIcsapIndicators({
1015
- filters,
1016
- groupBy: args.group_by,
1017
- universe: args.universe,
1018
- });
1019
- const raceN = args.group_by?.includes("race") ? raceNotes(args.year, getAvailableYears()) : [];
1020
- return {
1021
- data,
1022
- ...notesField(args.year, { icsap: true, value: true }, [...raceN, universeNote(args.universe)]),
1023
- indicators_calculated: ["icsap_percentage"],
1024
- note: "icsap_percentage = (n_icsap / n_total) * 100",
1025
- };
1026
- }
1027
- catch (error) {
1028
- return {
1029
- error: error instanceof Error ? error.message : "Erro no cálculo",
1030
- data: [],
1031
- };
1032
- }
974
+ const data = await calculateIcsapIndicators({
975
+ filters,
976
+ groupBy: args.group_by,
977
+ universe: args.universe,
978
+ });
979
+ const raceN = args.group_by?.includes("race") ? raceNotes(args.year, getAvailableYears()) : [];
980
+ return {
981
+ data,
982
+ ...notesField(args.year, { icsap: true, value: true }, [...raceN, universeNote(args.universe)]),
983
+ indicators_calculated: ["icsap_percentage"],
984
+ note: "icsap_percentage = (n_icsap / n_total) * 100",
985
+ };
1033
986
  }
1034
987
  async function handleRankCsapGroups(args) {
1035
988
  const filters = {
@@ -1039,61 +992,52 @@ async function handleRankCsapGroups(args) {
1039
992
  ageMin: args.age_min,
1040
993
  ageMax: args.age_max,
1041
994
  };
1042
- try {
1043
- const data = await rankCsapGroups({
1044
- filters,
1045
- metric: args.metric || "n",
1046
- limit: args.limit || 19,
1047
- universe: args.universe,
1048
- });
1049
- // Adiciona nomes dos grupos e calcula percentuais
1050
- const totalMetric = data.reduce((acc, row) => acc + (Number(row.metric_value) || 0), 0);
1051
- const ranking = data.map((row, index) => {
1052
- const group = csapGroups.groups.find((g) => g.code === row.csap_group);
1053
- return {
1054
- rank: index + 1,
1055
- csap_group: row.csap_group,
1056
- csap_name: group?.name_pt || "Desconhecido",
1057
- metric_value: row.metric_value,
1058
- pct_of_total: totalMetric > 0
1059
- ? Math.round((Number(row.metric_value) / totalMetric) * 10000) / 100
1060
- : 0,
1061
- n_hospitalizations: row.n_hospitalizations,
1062
- total_days: row.total_days,
1063
- total_value: row.total_value,
1064
- deaths: row.deaths,
1065
- };
1066
- });
1067
- // Concentração nos top 3 e top 5
1068
- const top3Pct = ranking.slice(0, 3).reduce((acc, r) => acc + r.pct_of_total, 0);
1069
- const top5Pct = ranking.slice(0, 5).reduce((acc, r) => acc + r.pct_of_total, 0);
1070
- return {
1071
- metric: args.metric || "n",
1072
- ranking,
1073
- ...notesField(args.year, { icsap: true, value: true }, [universeNote(args.universe)]),
1074
- concentration: {
1075
- top_3_percentage: Math.round(top3Pct * 100) / 100,
1076
- top_5_percentage: Math.round(top5Pct * 100) / 100,
1077
- },
1078
- total_groups: ranking.length,
1079
- };
1080
- }
1081
- catch (error) {
995
+ const data = await rankCsapGroups({
996
+ filters,
997
+ metric: args.metric || "n",
998
+ limit: args.limit || 19,
999
+ universe: args.universe,
1000
+ });
1001
+ // Adiciona nomes dos grupos e calcula percentuais
1002
+ const totalMetric = data.reduce((acc, row) => acc + (Number(row.metric_value) || 0), 0);
1003
+ const ranking = data.map((row, index) => {
1004
+ const group = csapGroups.groups.find((g) => g.code === row.csap_group);
1082
1005
  return {
1083
- error: error instanceof Error ? error.message : "Erro no ranking",
1084
- ranking: [],
1006
+ rank: index + 1,
1007
+ csap_group: row.csap_group,
1008
+ csap_name: group?.name_pt || "Desconhecido",
1009
+ metric_value: row.metric_value,
1010
+ pct_of_total: totalMetric > 0
1011
+ ? Math.round((Number(row.metric_value) / totalMetric) * 10000) / 100
1012
+ : 0,
1013
+ n_hospitalizations: row.n_hospitalizations,
1014
+ total_days: row.total_days,
1015
+ total_value: row.total_value,
1016
+ deaths: row.deaths,
1085
1017
  };
1086
- }
1018
+ });
1019
+ // Concentração nos top 3 e top 5
1020
+ const top3Pct = ranking.slice(0, 3).reduce((acc, r) => acc + r.pct_of_total, 0);
1021
+ const top5Pct = ranking.slice(0, 5).reduce((acc, r) => acc + r.pct_of_total, 0);
1022
+ return {
1023
+ metric: args.metric || "n",
1024
+ ranking,
1025
+ ...notesField(args.year, { icsap: true, value: true }, [universeNote(args.universe)]),
1026
+ concentration: {
1027
+ top_3_percentage: Math.round(top3Pct * 100) / 100,
1028
+ top_5_percentage: Math.round(top5Pct * 100) / 100,
1029
+ },
1030
+ total_groups: ranking.length,
1031
+ };
1087
1032
  }
1088
1033
  async function handleGetHospitalizationRates(args) {
1089
1034
  // Verifica se dados populacionais estão disponíveis (pasta de dados ou cache
1090
1035
  // local, enchido do canal por ensurePopulation() antes deste handler)
1091
1036
  if (!hasPopulationData()) {
1092
- return {
1093
- error: POPULATION_MISSING_MESSAGE,
1094
- data: [],
1095
- note: `Esta ferramenta requer pop_uf.parquet (ou pop_uf_agregado/pop_municipios) em ${getPopulationDir()}; o canal ${CUBES_BASE_URL} publica os três no bloco population do manifest.json.`,
1096
- };
1037
+ // Falha da FONTE (o download do bloco population não chegou), não ausência
1038
+ // respondida: sai como erro de verdade, com classe, e não como `{ error }`
1039
+ // num sucesso que a telemetria contava `ok` (decisão 49).
1040
+ throw new FalhaDaFonte(`${POPULATION_MISSING_MESSAGE} Esta ferramenta requer pop_uf.parquet (ou pop_uf_agregado/pop_municipios) em ${getPopulationDir()}; o canal ${CUBES_BASE_URL} publica os três no bloco population do manifest.json.`);
1097
1041
  }
1098
1042
  // Valida anos pela cobertura REAL dos arquivos de população (lida deles, não
1099
1043
  // fixada aqui): idade simples de 2000 em diante (pop_uf) e faixa etária
@@ -1148,124 +1092,116 @@ async function handleGetHospitalizationRates(args) {
1148
1092
  const popSources = {};
1149
1093
  for (const y of validYears)
1150
1094
  popSources[y] = await populationSourceFor(y);
1151
- try {
1152
- // Define agrupamento: se uf ou year foram passados sem group_by, agrupa automaticamente
1153
- const groupBy = args.group_by ? [...args.group_by] : [];
1154
- if (normalizedUfs && normalizedUfs.length > 1 && !groupBy.includes("uf")) {
1155
- groupBy.push("uf");
1156
- }
1157
- if (validYears.length > 1 && !groupBy.includes("year")) {
1158
- groupBy.push("year");
1159
- }
1160
- const hasUfGrouping = groupBy.includes("uf");
1161
- const hasYearGrouping = groupBy.includes("year");
1162
- // Busca internações — cubo LEVE quando o recorte cabe (0.14.2): a taxa usa
1163
- // contagem e óbitos, que as séries têm. É o caminho da pergunta longa
1164
- // ("taxa por 100 mil ao longo do tempo"), que no cubo de causas custava
1165
- // 1.253 MB de download.
1166
- const hospData = seriesFitsCall("get_hospitalization_rates", args)
1167
- ? await querySeriesYearly({
1168
- years: validYears,
1169
- ufs: normalizedUfs,
1170
- cidChapters: args.cid_chapter,
1171
- groupBy,
1172
- })
1173
- : await queryCausas({
1174
- filters,
1175
- groupBy,
1176
- metrics: ["n", "deaths"],
1177
- });
1178
- // Filtra resultados nulos (quando GROUP BY vazio e sem dados, retorna {null, null})
1179
- const validHospData = hospData.filter(row => row.n_hospitalizations !== null && row.n_hospitalizations !== undefined);
1180
- if (validHospData.length === 0) {
1181
- // Sem internações, mas podemos ainda fornecer a população
1182
- const popYear = validYears[0];
1183
- let population = 0;
1184
- try {
1185
- population = await getPopulation({ year: popYear, uf: normalizedUfs, sex: args.sex, ageMin: args.age_min, ageMax: args.age_max });
1186
- }
1187
- catch { /* ignore */ }
1188
- return {
1189
- data: [],
1190
- summary: {
1191
- total_hospitalizations: 0,
1192
- total_population: population,
1193
- overall_rate: 0,
1194
- rate_per: ratePer,
1195
- rate_type: args.rate_type || "crude",
1196
- },
1197
- metadata: {
1198
- population_source: popSources,
1199
- filters_applied: { ...args, uf: normalizedUfs, year: validYears },
1200
- note: "Nenhuma internação encontrada para os filtros aplicados.",
1201
- available_sih_years: availableYears,
1202
- },
1203
- };
1204
- }
1205
- // Busca população correspondente e calcula taxas
1206
- const results = [];
1207
- for (const row of validHospData) {
1208
- const hospYear = hasYearGrouping ? Number(row.year) : validYears[0];
1209
- const hospUf = hasUfGrouping ? String(row.uf) : undefined;
1210
- // Busca população para o estrato
1211
- let population = 0;
1212
- try {
1213
- population = await getPopulation({
1214
- year: hospYear,
1215
- uf: hospUf ? [hospUf] : normalizedUfs,
1216
- sex: args.sex,
1217
- ageMin: args.age_min,
1218
- ageMax: args.age_max,
1219
- });
1220
- }
1221
- catch (popError) {
1222
- console.error(`[get_hospitalization_rates] Erro população (year=${hospYear}, uf=${hospUf}): ${popError}`);
1223
- }
1224
- const nHosp = Number(row.n_hospitalizations) || 0;
1225
- const deaths = Number(row.deaths) || 0;
1226
- const rate = population > 0 ? (nHosp / population) * ratePer : 0;
1227
- results.push({
1228
- ...(hasYearGrouping ? { year: hospYear } : {}),
1229
- ...(hasUfGrouping ? { uf: hospUf } : {}),
1230
- n_hospitalizations: nHosp,
1231
- deaths,
1232
- population,
1233
- rate: Math.round(rate * 100) / 100,
1234
- rate_per: ratePer,
1235
- // Deprecado: mantido uma versão para quem lia o nome antigo. Até a 1.1.x
1236
- // este campo carregava a taxa na base `rate_per` (por mil, por 10 mil),
1237
- // contradizendo o próprio nome; agora vale sempre por 100 mil.
1238
- rate_per_100k: population > 0 ? Math.round((nHosp / population) * 100000 * 100) / 100 : 0,
1239
- population_source: popSources[hospYear] ?? null,
1240
- mortality_rate: nHosp > 0 ? Math.round((deaths / nHosp) * 10000) / 100 : 0,
1241
- });
1095
+ // Define agrupamento: se uf ou year foram passados sem group_by, agrupa automaticamente
1096
+ const groupBy = args.group_by ? [...args.group_by] : [];
1097
+ if (normalizedUfs && normalizedUfs.length > 1 && !groupBy.includes("uf")) {
1098
+ groupBy.push("uf");
1099
+ }
1100
+ if (validYears.length > 1 && !groupBy.includes("year")) {
1101
+ groupBy.push("year");
1102
+ }
1103
+ const hasUfGrouping = groupBy.includes("uf");
1104
+ const hasYearGrouping = groupBy.includes("year");
1105
+ // Busca internações — cubo LEVE quando o recorte cabe (0.14.2): a taxa usa
1106
+ // contagem e óbitos, que as séries têm. É o caminho da pergunta longa
1107
+ // ("taxa por 100 mil ao longo do tempo"), que no cubo de causas custava
1108
+ // 1.253 MB de download.
1109
+ const hospData = seriesFitsCall("get_hospitalization_rates", args)
1110
+ ? await querySeriesYearly({
1111
+ years: validYears,
1112
+ ufs: normalizedUfs,
1113
+ cidChapters: args.cid_chapter,
1114
+ groupBy,
1115
+ })
1116
+ : await queryCausas({
1117
+ filters,
1118
+ groupBy,
1119
+ metrics: ["n", "deaths"],
1120
+ });
1121
+ // Filtra resultados nulos (quando GROUP BY vazio e sem dados, retorna {null, null})
1122
+ const validHospData = hospData.filter(row => row.n_hospitalizations !== null && row.n_hospitalizations !== undefined);
1123
+ if (validHospData.length === 0) {
1124
+ // Sem internações, mas podemos ainda fornecer a população
1125
+ const popYear = validYears[0];
1126
+ let population = 0;
1127
+ try {
1128
+ population = await getPopulation({ year: popYear, uf: normalizedUfs, sex: args.sex, ageMin: args.age_min, ageMax: args.age_max });
1242
1129
  }
1243
- // Calcula totais
1244
- const totalHosp = results.reduce((acc, r) => acc + r.n_hospitalizations, 0);
1245
- const totalPop = results.reduce((acc, r) => acc + r.population, 0);
1246
- const overallRate = totalPop > 0 ? (totalHosp / totalPop) * ratePer : 0;
1130
+ catch { /* ignore */ }
1247
1131
  return {
1248
- data: results,
1132
+ data: [],
1249
1133
  summary: {
1250
- total_hospitalizations: totalHosp,
1251
- total_population: totalPop,
1252
- overall_rate: Math.round(overallRate * 100) / 100,
1134
+ total_hospitalizations: 0,
1135
+ total_population: population,
1136
+ overall_rate: 0,
1253
1137
  rate_per: ratePer,
1254
1138
  rate_type: args.rate_type || "crude",
1255
1139
  },
1256
1140
  metadata: {
1257
1141
  population_source: popSources,
1258
- population_notes: populationNotes(popSources),
1259
1142
  filters_applied: { ...args, uf: normalizedUfs, year: validYears },
1143
+ note: "Nenhuma internação encontrada para os filtros aplicados.",
1144
+ available_sih_years: availableYears,
1260
1145
  },
1261
1146
  };
1262
1147
  }
1263
- catch (error) {
1264
- return {
1265
- error: error instanceof Error ? error.message : "Erro ao calcular taxas",
1266
- data: [],
1267
- };
1148
+ // Busca população correspondente e calcula taxas
1149
+ const results = [];
1150
+ for (const row of validHospData) {
1151
+ const hospYear = hasYearGrouping ? Number(row.year) : validYears[0];
1152
+ const hospUf = hasUfGrouping ? String(row.uf) : undefined;
1153
+ // Busca população para o estrato
1154
+ let population = 0;
1155
+ try {
1156
+ population = await getPopulation({
1157
+ year: hospYear,
1158
+ uf: hospUf ? [hospUf] : normalizedUfs,
1159
+ sex: args.sex,
1160
+ ageMin: args.age_min,
1161
+ ageMax: args.age_max,
1162
+ });
1163
+ }
1164
+ catch (popError) {
1165
+ console.error(`[get_hospitalization_rates] Erro população (year=${hospYear}, uf=${hospUf}): ${popError}`);
1166
+ }
1167
+ const nHosp = Number(row.n_hospitalizations) || 0;
1168
+ const deaths = Number(row.deaths) || 0;
1169
+ const rate = population > 0 ? (nHosp / population) * ratePer : 0;
1170
+ results.push({
1171
+ ...(hasYearGrouping ? { year: hospYear } : {}),
1172
+ ...(hasUfGrouping ? { uf: hospUf } : {}),
1173
+ n_hospitalizations: nHosp,
1174
+ deaths,
1175
+ population,
1176
+ rate: Math.round(rate * 100) / 100,
1177
+ rate_per: ratePer,
1178
+ // Deprecado: mantido uma versão para quem lia o nome antigo. Até a 1.1.x
1179
+ // este campo carregava a taxa na base `rate_per` (por mil, por 10 mil),
1180
+ // contradizendo o próprio nome; agora vale sempre por 100 mil.
1181
+ rate_per_100k: population > 0 ? Math.round((nHosp / population) * 100000 * 100) / 100 : 0,
1182
+ population_source: popSources[hospYear] ?? null,
1183
+ mortality_rate: nHosp > 0 ? Math.round((deaths / nHosp) * 10000) / 100 : 0,
1184
+ });
1268
1185
  }
1186
+ // Calcula totais
1187
+ const totalHosp = results.reduce((acc, r) => acc + r.n_hospitalizations, 0);
1188
+ const totalPop = results.reduce((acc, r) => acc + r.population, 0);
1189
+ const overallRate = totalPop > 0 ? (totalHosp / totalPop) * ratePer : 0;
1190
+ return {
1191
+ data: results,
1192
+ summary: {
1193
+ total_hospitalizations: totalHosp,
1194
+ total_population: totalPop,
1195
+ overall_rate: Math.round(overallRate * 100) / 100,
1196
+ rate_per: ratePer,
1197
+ rate_type: args.rate_type || "crude",
1198
+ },
1199
+ metadata: {
1200
+ population_source: popSources,
1201
+ population_notes: populationNotes(popSources),
1202
+ filters_applied: { ...args, uf: normalizedUfs, year: validYears },
1203
+ },
1204
+ };
1269
1205
  }
1270
1206
  async function handleCompareIcsapTrends(args) {
1271
1207
  const { start_year, end_year, compare_by, indicator, include_trend_line } = args;
@@ -1287,10 +1223,8 @@ async function handleCompareIcsapTrends(args) {
1287
1223
  }
1288
1224
  // Se usa taxa, verifica população
1289
1225
  if (indicatorType === "rate_per_10k" && !hasPopulationData()) {
1290
- return {
1291
- error: `Taxa por população requer dados populacionais. ${POPULATION_MISSING_MESSAGE}`,
1292
- series: [],
1293
- };
1226
+ // Falha da fonte, como em handleGetHospitalizationRates (decisão 49).
1227
+ throw new FalhaDaFonte(`Taxa por população requer dados populacionais. ${POPULATION_MISSING_MESSAGE}`);
1294
1228
  }
1295
1229
  // Normaliza compare_values (UFs para uppercase)
1296
1230
  const compare_values = args.compare_values?.map(v => compare_by === "uf" ? v.toUpperCase() : v);
@@ -1305,152 +1239,144 @@ async function handleCompareIcsapTrends(args) {
1305
1239
  if (years.length === 0) {
1306
1240
  return respostaSemAnos(allYears, availableYears, "series");
1307
1241
  }
1308
- try {
1309
- const filters = {
1310
- years,
1311
- csapGroups: compare_by === "csap_group" ? compare_values : undefined,
1312
- ufs: compare_by === "uf" ? compare_values : undefined,
1313
- };
1314
- // Determina groupBy baseado em compare_by
1315
- const groupBy = ["year"];
1316
- if (compare_by) {
1317
- groupBy.push(compare_by);
1318
- }
1319
- // Busca dados ICSAP
1320
- const data = await calculateIcsapIndicators({
1321
- filters,
1322
- groupBy,
1323
- universe: args.universe,
1324
- });
1325
- // Organiza série temporal
1326
- const seriesMap = {};
1327
- for (const row of data) {
1328
- const year = Number(row.year);
1329
- const compareKey = compare_by ? String(row[compare_by]) : "total";
1330
- const icsap = Number(row.n_icsap) || 0;
1331
- const total = Number(row.n_total) || 0;
1332
- if (!seriesMap[compareKey]) {
1333
- seriesMap[compareKey] = {};
1334
- }
1335
- seriesMap[compareKey][year] = { icsap, total };
1336
- }
1337
- // Se precisa de taxa, busca população por ano
1338
- if (indicatorType === "rate_per_10k") {
1339
- for (const compareKey of Object.keys(seriesMap)) {
1340
- for (const year of years) {
1341
- if (seriesMap[compareKey][year]) {
1342
- try {
1343
- const ufFilter = compare_by === "uf" ? [compareKey] : undefined;
1344
- const pop = await getPopulation({ year, uf: ufFilter });
1345
- seriesMap[compareKey][year].population = pop;
1346
- }
1347
- catch {
1348
- seriesMap[compareKey][year].population = 0;
1349
- }
1350
- }
1351
- }
1352
- }
1242
+ const filters = {
1243
+ years,
1244
+ csapGroups: compare_by === "csap_group" ? compare_values : undefined,
1245
+ ufs: compare_by === "uf" ? compare_values : undefined,
1246
+ };
1247
+ // Determina groupBy baseado em compare_by
1248
+ const groupBy = ["year"];
1249
+ if (compare_by) {
1250
+ groupBy.push(compare_by);
1251
+ }
1252
+ // Busca dados ICSAP
1253
+ const data = await calculateIcsapIndicators({
1254
+ filters,
1255
+ groupBy,
1256
+ universe: args.universe,
1257
+ });
1258
+ // Organiza série temporal
1259
+ const seriesMap = {};
1260
+ for (const row of data) {
1261
+ const year = Number(row.year);
1262
+ const compareKey = compare_by ? String(row[compare_by]) : "total";
1263
+ const icsap = Number(row.n_icsap) || 0;
1264
+ const total = Number(row.n_total) || 0;
1265
+ if (!seriesMap[compareKey]) {
1266
+ seriesMap[compareKey] = {};
1353
1267
  }
1354
- // Constrói séries e calcula indicadores
1355
- const series = [];
1356
- const trendData = {};
1357
- for (const year of years) {
1358
- const row = { year };
1359
- for (const compareKey of Object.keys(seriesMap)) {
1360
- const entry = seriesMap[compareKey][year];
1361
- if (entry) {
1362
- let value;
1363
- switch (indicatorType) {
1364
- case "count":
1365
- value = entry.icsap;
1366
- break;
1367
- case "rate_per_10k":
1368
- value = entry.population && entry.population > 0
1369
- ? (entry.icsap / entry.population) * 10000
1370
- : 0;
1371
- break;
1372
- case "percentage":
1373
- default:
1374
- value = entry.total > 0 ? (entry.icsap / entry.total) * 100 : 0;
1268
+ seriesMap[compareKey][year] = { icsap, total };
1269
+ }
1270
+ // Se precisa de taxa, busca população por ano
1271
+ if (indicatorType === "rate_per_10k") {
1272
+ for (const compareKey of Object.keys(seriesMap)) {
1273
+ for (const year of years) {
1274
+ if (seriesMap[compareKey][year]) {
1275
+ try {
1276
+ const ufFilter = compare_by === "uf" ? [compareKey] : undefined;
1277
+ const pop = await getPopulation({ year, uf: ufFilter });
1278
+ seriesMap[compareKey][year].population = pop;
1375
1279
  }
1376
- row[compareKey] = Math.round(value * 100) / 100;
1377
- // Acumula para cálculo de tendência
1378
- if (!trendData[compareKey]) {
1379
- trendData[compareKey] = { values: [], years: [] };
1280
+ catch {
1281
+ seriesMap[compareKey][year].population = 0;
1380
1282
  }
1381
- trendData[compareKey].values.push(value);
1382
- trendData[compareKey].years.push(year);
1383
1283
  }
1384
1284
  }
1385
- series.push(row);
1386
1285
  }
1387
- // Calcula tendências (regressão linear simples)
1388
- const trends = {};
1389
- if (includeTrend) {
1390
- for (const [compareKey, td] of Object.entries(trendData)) {
1391
- const n = td.values.length;
1392
- if (n < 2)
1393
- continue;
1394
- // Regressão linear simples
1395
- const sumX = td.years.reduce((a, b) => a + b, 0);
1396
- const sumY = td.values.reduce((a, b) => a + b, 0);
1397
- const sumXY = td.years.reduce((acc, x, i) => acc + x * td.values[i], 0);
1398
- const sumX2 = td.years.reduce((acc, x) => acc + x * x, 0);
1399
- const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
1400
- const startValue = td.values[0];
1401
- const endValue = td.values[n - 1];
1402
- const changePct = startValue > 0 ? ((endValue - startValue) / startValue) * 100 : 0;
1403
- trends[compareKey] = {
1404
- slope: Math.round(slope * 1000) / 1000,
1405
- direction: slope > 0.1 ? "increasing" : slope < -0.1 ? "decreasing" : "stable",
1406
- avg_annual_change: Math.round(slope * 100) / 100,
1407
- start_value: Math.round(startValue * 100) / 100,
1408
- end_value: Math.round(endValue * 100) / 100,
1409
- change_pct: Math.round(changePct * 100) / 100,
1410
- };
1286
+ }
1287
+ // Constrói séries e calcula indicadores
1288
+ const series = [];
1289
+ const trendData = {};
1290
+ for (const year of years) {
1291
+ const row = { year };
1292
+ for (const compareKey of Object.keys(seriesMap)) {
1293
+ const entry = seriesMap[compareKey][year];
1294
+ if (entry) {
1295
+ let value;
1296
+ switch (indicatorType) {
1297
+ case "count":
1298
+ value = entry.icsap;
1299
+ break;
1300
+ case "rate_per_10k":
1301
+ value = entry.population && entry.population > 0
1302
+ ? (entry.icsap / entry.population) * 10000
1303
+ : 0;
1304
+ break;
1305
+ case "percentage":
1306
+ default:
1307
+ value = entry.total > 0 ? (entry.icsap / entry.total) * 100 : 0;
1308
+ }
1309
+ row[compareKey] = Math.round(value * 100) / 100;
1310
+ // Acumula para cálculo de tendência
1311
+ if (!trendData[compareKey]) {
1312
+ trendData[compareKey] = { values: [], years: [] };
1313
+ }
1314
+ trendData[compareKey].values.push(value);
1315
+ trendData[compareKey].years.push(year);
1411
1316
  }
1412
1317
  }
1413
- // Identifica melhor/pior desempenho (para percentage, menor é melhor)
1414
- let bestPerformer;
1415
- let worstPerformer;
1416
- if (Object.keys(trends).length > 1) {
1417
- const sortedByChange = Object.entries(trends).sort((a, b) => a[1].change_pct - b[1].change_pct);
1418
- if (indicatorType === "percentage") {
1419
- // Para porcentagem ICSAP, queda é melhor
1420
- bestPerformer = sortedByChange[0][0];
1421
- worstPerformer = sortedByChange[sortedByChange.length - 1][0];
1422
- }
1423
- else {
1424
- // Para contagem e taxa, menor variação positiva ou maior queda é melhor
1425
- bestPerformer = sortedByChange[0][0];
1426
- worstPerformer = sortedByChange[sortedByChange.length - 1][0];
1427
- }
1318
+ series.push(row);
1319
+ }
1320
+ // Calcula tendências (regressão linear simples)
1321
+ const trends = {};
1322
+ if (includeTrend) {
1323
+ for (const [compareKey, td] of Object.entries(trendData)) {
1324
+ const n = td.values.length;
1325
+ if (n < 2)
1326
+ continue;
1327
+ // Regressão linear simples
1328
+ const sumX = td.years.reduce((a, b) => a + b, 0);
1329
+ const sumY = td.values.reduce((a, b) => a + b, 0);
1330
+ const sumXY = td.years.reduce((acc, x, i) => acc + x * td.values[i], 0);
1331
+ const sumX2 = td.years.reduce((acc, x) => acc + x * x, 0);
1332
+ const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
1333
+ const startValue = td.values[0];
1334
+ const endValue = td.values[n - 1];
1335
+ const changePct = startValue > 0 ? ((endValue - startValue) / startValue) * 100 : 0;
1336
+ trends[compareKey] = {
1337
+ slope: Math.round(slope * 1000) / 1000,
1338
+ direction: slope > 0.1 ? "increasing" : slope < -0.1 ? "decreasing" : "stable",
1339
+ avg_annual_change: Math.round(slope * 100) / 100,
1340
+ start_value: Math.round(startValue * 100) / 100,
1341
+ end_value: Math.round(endValue * 100) / 100,
1342
+ change_pct: Math.round(changePct * 100) / 100,
1343
+ };
1428
1344
  }
1429
- return {
1430
- indicator: indicatorType,
1431
- period: { start: start_year, end: end_year },
1432
- compare_by: compare_by || "total",
1433
- series,
1434
- ...notesField(years, { icsap: true }, [
1435
- universeNote(args.universe),
1436
- ...(indicatorType === "rate_per_10k" ? populationNotes(await popSourcesFor(years)) : []),
1437
- ]),
1438
- trends: includeTrend ? trends : undefined,
1439
- summary: {
1440
- best_performer: bestPerformer,
1441
- worst_performer: worstPerformer,
1442
- note: indicatorType === "percentage"
1443
- ? "Para % ICSAP, queda indica melhoria na Atenção Primária"
1444
- : undefined,
1445
- },
1446
- };
1447
1345
  }
1448
- catch (error) {
1449
- return {
1450
- error: error instanceof Error ? error.message : "Erro na análise de tendências",
1451
- series: [],
1452
- };
1346
+ // Identifica melhor/pior desempenho (para percentage, menor é melhor)
1347
+ let bestPerformer;
1348
+ let worstPerformer;
1349
+ if (Object.keys(trends).length > 1) {
1350
+ const sortedByChange = Object.entries(trends).sort((a, b) => a[1].change_pct - b[1].change_pct);
1351
+ if (indicatorType === "percentage") {
1352
+ // Para porcentagem ICSAP, queda é melhor
1353
+ bestPerformer = sortedByChange[0][0];
1354
+ worstPerformer = sortedByChange[sortedByChange.length - 1][0];
1355
+ }
1356
+ else {
1357
+ // Para contagem e taxa, menor variação positiva ou maior queda é melhor
1358
+ bestPerformer = sortedByChange[0][0];
1359
+ worstPerformer = sortedByChange[sortedByChange.length - 1][0];
1360
+ }
1453
1361
  }
1362
+ return {
1363
+ indicator: indicatorType,
1364
+ period: { start: start_year, end: end_year },
1365
+ compare_by: compare_by || "total",
1366
+ series,
1367
+ ...notesField(years, { icsap: true }, [
1368
+ universeNote(args.universe),
1369
+ ...(indicatorType === "rate_per_10k" ? populationNotes(await popSourcesFor(years)) : []),
1370
+ ]),
1371
+ trends: includeTrend ? trends : undefined,
1372
+ summary: {
1373
+ best_performer: bestPerformer,
1374
+ worst_performer: worstPerformer,
1375
+ note: indicatorType === "percentage"
1376
+ ? "Para % ICSAP, queda indica melhoria na Atenção Primária"
1377
+ : undefined,
1378
+ },
1379
+ };
1454
1380
  }
1455
1381
  // =============================================================================
1456
1382
  // DESPACHO — o mesmo para os dois transportes (stdio em src/index.ts, HTTP em